Skip to content

fix(discover): honor an enclosing repo's .gitignore for a git-less subfolder - #2181

Open
AmirF194 wants to merge 3 commits into
DeusData:mainfrom
AmirF194:fix/510-enclosing-gitignore-not-inherited
Open

AmirF194 wants to merge 3 commits into
DeusData:mainfrom
AmirF194:fix/510-enclosing-gitignore-not-inherited

Conversation

@AmirF194

Copy link
Copy Markdown
Contributor

discover_impl only ever loaded .gitignore from repo_path itself (fixed for the first half of #510 by 3f754cf) and, when repo_path carries its own .git, from that repo's info/exclude. When repo_path is a subfolder with no .git of its own, the enclosing repository's root .gitignore was never consulted, so files the enclosing repo ignores (secrets, build output) get indexed and become searchable.

Added resolve_enclosing_git_root(): when resolve_git_common_dir(repo_path, ...) fails, walk up parent directories the same way git does from a subfolder, and load the first ancestor's .gitignore (plus its info/exclude, since is_git_repo becomes true once an ancestor is found). It's merged ahead of repo_path's own .gitignore so the more specific file still wins on conflict via cbm_gitignore_merge, matching git's shallow-to-deep precedence.

Added three tests: enclosing root .gitignore honored, enclosing info/exclude honored, and the indexed subfolder's own .gitignore still overriding the enclosing one on conflict. All three go red without the fix. Ran scripts/test.sh (every suite, ASan+UBSan) on this branch and it's fully green. clang-format is clean on both files; cppcheck/clang-tidy flag a couple of pre-existing things in compat_regex.c/cli.c/mcp.c that this diff doesn't touch, same result on plain main.

Fixes #510

…bfolder

resolve_git_common_dir() only stats repo_path/.git directly, so indexing a
subfolder with no .git of its own never consulted any ancestor repo's
.gitignore. Add resolve_enclosing_git_root() to walk up parent directories
the way git itself does, and merge that ancestor's .gitignore (and
info/exclude) ahead of the subfolder's own so the more specific file still
wins on conflict.

Fixes DeusData#510

Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>
@AmirF194
AmirF194 requested a review from DeusData as a code owner September 11, 2026 21:50
@github-actions

Copy link
Copy Markdown

Thanks for opening this — it has been seen, and it is queued.

This note is automated, but it is not a brush-off: it exists so you know where your PR stands instead of having to guess from silence.

Current review status: working through a backlog. 0.9.1-rc.1 is out, so the release freeze that held reviews is over — but it left a large queue of open pull requests behind it, and we are reading through them oldest-first. The background is in discussion #1144.

What that means for this PR, concretely:

  • It will not be closed for inactivity. No stale bot touches pull requests here.
  • It may still sit a while before a human reads it. That is on us, not on you.
  • Older PRs are read first, so a recent one is not being skipped — it is behind a queue.

Things that will genuinely speed it up whenever review does happen:

  • Keep it rebased on main — the tree is moving quickly right now, and a conflicting branch cannot be reviewed as the diff you intended.
  • Get CI green, or say which failures you believe are pre-existing.
  • Keep the change to one claim. Bundled features and refactors get split before they get merged, which costs you a round trip.
  • Every commit needs a sign-off (git commit -s) — CI enforces DCO.

If this fixes a bug, a reproduction we can run is worth more than a description of the symptom.

Thanks for contributing, and sorry in advance for the wait.

@AmirF194

Copy link
Copy Markdown
Contributor Author

The one red leg here (test / test-windows-guards, a concurrent-daemon-spawn stress test) is outside this diff, we only touch src/discover/discover.c and tests/test_discover.c. Been open a week with no look yet.

@DeusData DeusData left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this, and apologies that it sat eleven days without a maintainer word — that is on us, not you. The diagnosis is right (#510's second half is exactly "the enclosing repo's rules are never consulted from a git-less subfolder"), the parent walk is sound and bounded (it stops at /, at a bare drive root and at a separator-less string; I traced UNC and //?/ spellings and they only add a stat probe), the symmetry with resolve_git_common_dir is the right shape, and the three tests are RED for the right reason: with the production hunk reverted on today's main (8b57fe2a) exactly discover_enclosing_repo_gitignore_issue510, discover_enclosing_repo_info_exclude and discover_enclosing_repo_gitignore_local_overrides fail (count == 2, expected 1), and re-applied it is 124/124 on discover, 309/309 on gitignore + pipeline, memory-core linter unchanged, merge with main clean.

One thing blocks it, and it is a correctness problem rather than polish: anchored patterns from the enclosing repo are re-anchored to the indexed subfolder. The ancestor's patterns are merged into the same matcher whose rel_path is relative to repo_path (src/discover/discover.c, the cbm_gitignore_merge after resolve_enclosing_git_root), and a rooted pattern is matched against that path directly (src/discover/gitignore.c, p->rooted ? glob_match_bounded(p->pattern, rel_path) : …). Git evaluates every .gitignore relative to the directory that contains it. Two probes on the merge result, each checked against git check-ignore run from pkg/:

  • root .gitignore = /secret.py → this branch skips pkg/secret.py; git does not ignore it. A root line like /build, /dist or /src (very common) now hides the same-named entry inside every indexed subfolder — silent index loss, which is the one thing discovery must never do.
  • root info/exclude = pkg/scratch/ → this branch indexes pkg/scratch/tmp.py; git ignores it. So the "secrets and build output get indexed" case from #510 persists for exactly the anchored form people write for a subdirectory.

Your three tests all use unanchored patterns (secret.py, *.log, scratch/), which match at any depth, so the suite cannot see either direction.

What I would ask for:

  1. Keep the enclosing repo's matcher(s) separate instead of merging them into repo_path's, and evaluate each against the path relative to the directory its file lives in — i.e. <repo_path relative to that directory>/<rel_path>. The gitignore_link_t chain in discover.c already encodes "deepest file with an opinion wins" for directories below the walk root; this is the mirror image (a base offset applied to rel_path) rather than a plain merge.
  2. Load the .gitignore of every directory from the enclosing root down to repo_path, shallow to deep, not only the root's — git consults all of them.
  3. Canonicalize repo_path (cbm_canonical_path in src/foundation/compat_fs.h) before the lexical walk: as written, cbm index pkg and cbm index /abs/path/pkg discover different things, because the walk is purely lexical on the caller's string.
  4. Tests for both anchored directions, mirroring the two probes: root /secret.py must not hide pkg/secret.py, root pkg/scratch/ must hide pkg/scratch/tmp.py, plus one intermediate-directory .gitignore case.
  5. Small and optional: on a failed merge the code currently drops the more specific local patterns and keeps the ancestor's (the inverse of the documented policy on the info/exclude merge just below), and setting is_git_repo = true newly enables the user's global core.excludesfile for git-less subfolders — correct per git, but worth a line in the PR body since it is a visible behaviour change.

The Windows guard failure on your run is ours (a cold-start race in the daemon endpoint, fixed in #2275), not anything in this diff; I will update the branch once that lands so you get a clean run. If you would rather I take the base-relative matching from here, say so and I will, with your commits kept as the foundation — but the design is yours and I would be glad to see you finish it.

@AmirF194

Copy link
Copy Markdown
Contributor Author

You're right, and the two probes make it obvious once I see them: root /secret.py should never hide pkg/secret.py, and the merge collapses that distinction. Good catch on the intermediate directory case too, my tests never exercised a .gitignore sitting between the enclosing root and repo_path.

Take it from here. The matcher-offset redesign and the chain walk down from the enclosing root need the kind of familiarity with gitignore_link_t you already have, and guessing at that architecture is more likely to hand you a second bug than fix the first. Keep the commit as the foundation.

Thanks for tracing the Windows failure to #2275, that saves me chasing a dead end.

…to their own directory

The previous commit found the enclosing repository when a git-less subfolder
is indexed, but merged its patterns into the matcher whose rel_path is
relative to repo_path. gitignore.c matches a rooted pattern with
`glob_match_bounded(p->pattern, rel_path)`, so every anchored pattern of the
enclosing repository was silently re-anchored onto the indexed subfolder.
That is wrong in both directions, and both were checked against real
`git check-ignore` run inside the subfolder:

  * enclosing root .gitignore `/secret.py`, indexing pkg/
    git INDEXES pkg/secret.py — we HID it. A rooted pattern means "in the
    repository root", and re-anchored it became "in pkg". Silent index loss:
    the file is gone from the graph with no diagnostic, the worst failure
    mode discovery has.
  * enclosing .git/info/exclude `pkg/scratch/`, indexing pkg/
    git IGNORES pkg/scratch/tmp.py — we INDEXED it. The pattern is anchored
    THROUGH pkg, so once rel_path lost the "pkg/" prefix it stopped matching
    and an excluded directory got walked.

Only the enclosing root's .gitignore was consulted, too; git reads the
.gitignore of every directory between the repository root and the indexed
one, deeper overriding shallower.

Mechanism. discover.c already had the chain that encodes "the deepest file
with an opinion wins": gitignore_link_t carries a `prefix`, the walk-relative
directory a nested matcher came from, which local_rel_path() strips off. An
ancestor is the mirror image of that, so the chain is extended rather than
duplicated: each link now also carries a `base`, the walk root's path
relative to the directory the matcher came from, which gitignore_chain_result
PREPENDS instead of stripping (discover.c:569, 610). Exactly one of the two
offsets is ever non-empty, and both live in the link's flexible tail, so no
allocation site is added.

  * ancestor_ignores_build() (discover.c:1282) loads the .gitignore of every
    directory from the enclosing repository root down to — but not including —
    the indexed one, shallow to deep, and chains them so the deepest wins,
    negations included. The enclosing repository's info/exclude is merged into
    the ROOT ancestor's matcher, which is where git anchors it, keeping the
    precedence info/exclude already has over .gitignore at the same level.
  * the indexed directory's own .gitignore stays its own matcher at the leaf
    of that chain (discover.c:1480), so it still overrides every ancestor.
    Nothing is merged into it any more, which also removes the earlier merge's
    failure mode of dropping the local patterns and keeping the ancestor's.
  * repo_path is canonicalized with cbm_canonical_path() before the lexical
    parent walk (discover.c:1414), so `cbm index pkg` and
    `cbm index /abs/path/pkg` discover the same files; pipeline.c strdups the
    caller's path unchanged.
  * walk_owned_gitignore_free() (discover.c:1019) is factored out of walk_dir
    and shared with the ancestor chain, which owns its matchers the same way.

Proof on real input, before the suites. Indexing scripts/ of this checkout —
a git-less subfolder whose root .gitignore carries the rooted patterns
`/memlab-*` and `/soak*/`:

  before   87 files   memlab-drive.py MISSING   memlab-report.py MISSING
  after    89 files   memlab-drive.py present   memlab-report.py present

`git check-ignore` inside scripts/ indexes both, so the two recovered files
are exactly the ones git keeps. No file is lost in the other direction.

RED on the previous commit, four new tests, each direction pinned to a
`git check-ignore` verdict on an identical fixture:

  test_discover.c:1471: count == 1, expected 2 == 2   rooted_pattern_not_reanchored
  test_discover.c:1498: count == 2, expected 1 == 1   info_exclude_rooted_subpath
  test_discover.c:1526: count == 2, expected 1 == 1   intermediate_gitignore
  test_discover.c:1555: count == 0, expected 1 == 1   deeper_ancestor_negation_wins
  discover: 120 passed, 4 failed

The three tests already on the branch use unanchored patterns, which match at
any depth and so cannot see this bug; they pass before and after.

GREEN: discover 124 passed, 0 failed; discover + gitignore + pipeline +
git_context 425 passed, 0 failed.

Revert-check: reverting only the discover.c hunks and rebuilding puts the
four new tests RED again on the same four lines with the same counts
(120 passed, 4 failed); restoring returns 425 passed. Memory-core linter:
none grew (31 raw sites in discover.c before and after). clang-format clean.

Co-authored-by: Amir Fathi <amirfathi.me@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
@DeusData

Copy link
Copy Markdown
Owner

Thank you for handing this over so clearly, and for keeping the door open instead of guessing at the chain code. I took you at your word: your commit stays exactly as you wrote it, as the foundation, and I added one commit on top of it on this branch, with you credited as co-author.

What the added commit does:

  • Base-relative matching. The enclosing repository's patterns are no longer merged into the indexed folder's matcher. The existing gitignore_link_t chain already carried a prefix for matchers below the walk root; an ancestor is the mirror image, so each link now also carries a base — the walk root's path relative to the matcher's own directory — which is prepended rather than stripped. A rooted /secret.py in the enclosing root now means the root's secret.py, as git means it.
  • Every intermediate directory. The .gitignore of each directory from the enclosing root down to the indexed folder is loaded, shallow to deep, chained so the deepest opinion wins, negations included. The enclosing repository's info/exclude sits with the root ancestor's matcher, where git anchors it.
  • Canonical path first. repo_path goes through cbm_canonical_path before the parent walk, so indexing pkg and /abs/path/pkg discover the same files.
  • Nothing is merged into the indexed folder's own matcher any more, which also removes the old failed-merge path that kept the ancestor's patterns and dropped the local ones.

Tests: four new cases, each pinned first to real git check-ignore run inside the subfolder on an identical fixture — a rooted pattern that must not be re-anchored, an info/exclude sub-path that must apply, an intermediate .gitignore, and a deeper negation that must win. On your commit alone they fail (discover 120 passed, 4 failed); with the change they pass, and reverting just the production hunks brings back exactly the same four failures. Your three tests pass throughout. Memory-core linter unchanged. I also checked both commits merged with today's main, since your branch predates several merges: discover 128, gitignore 22, pipeline 287 and git_context 3 passed, with a clean merge.

The real-input check is the part I like best: this repository's own scripts/ folder is a git-less subfolder under a root .gitignore with rooted /memlab-*. With your commit alone, indexing scripts/ silently dropped memlab-drive.py and memlab-report.py, which git keeps; with the change both are back. So the defect was losing real files here, not only in the synthetic probes.

Honest limits: this was run on macOS only; the Windows legs of CI on this push are the first Windows run of the change, and the drive-root and UNC stop conditions are still your original loop, untouched. Symlinked repo_path values now resolve to the physical path, where git uses the logical one; no test covers that yet. If the Windows guard job goes red, that is the daemon endpoint cold-start race on our side (#2275), not this diff.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.gitignore (non repo root) gaps and overrides

2 participants